@oar-sh/oar 0.9.24 → 0.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +142 -0
  3. package/README.md +12 -11
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  8. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  9. package/server/db-schema.mjs +19 -0
  10. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  11. package/server/migrations/0039-media-index.mjs +167 -0
  12. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  13. package/server/public/app/api-client.js +58 -0
  14. package/server/public/app/ask-user-view.js +2 -1
  15. package/server/public/app/attachments-view.js +66 -9
  16. package/server/public/app/bootstrap.js +85 -25
  17. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  18. package/server/public/app/context-media-tab.js +143 -0
  19. package/server/public/app/conversation-view.js +90 -5
  20. package/server/public/app/image-composer-ui.js +518 -0
  21. package/server/public/app/image-turn-placeholder.mjs +233 -0
  22. package/server/public/app/journal-view.js +12 -2
  23. package/server/public/app/media-gallery-model.mjs +202 -0
  24. package/server/public/app/media-gallery-ui.js +524 -0
  25. package/server/public/app/media-view.js +79 -0
  26. package/server/public/app/openai-image-composer.mjs +492 -0
  27. package/server/public/app/openai-image-cost-ui.js +426 -0
  28. package/server/public/app/openai-image-spend.mjs +443 -0
  29. package/server/public/app/plan-usage-view.mjs +38 -6
  30. package/server/public/app/relay-error-ctas.mjs +5 -0
  31. package/server/public/app/session-history-ui.js +2 -0
  32. package/server/public/app/settings-modal.js +4 -0
  33. package/server/public/app/socket-handlers.js +26 -0
  34. package/server/public/app/tab-strip-fade.mjs +1 -1
  35. package/server/public/app/viewer-gallery.mjs +23 -1
  36. package/server/public/index.html +383 -7
  37. package/server/relay-tools.md +1 -0
  38. package/server/repositories/image-conversation-repository.mjs +69 -0
  39. package/server/repositories/media-repository.mjs +305 -0
  40. package/server/routes/media-routes.mjs +361 -0
  41. package/server/routes/messages-routes.mjs +1410 -360
  42. package/server/routes/sessions-routes.mjs +361 -14
  43. package/server/server-runtime.mjs +19 -0
  44. package/server/services/database-file.mjs +6 -4
  45. package/server/services/generated-image-cleanup-service.mjs +2 -1
  46. package/server/services/generated-image-files.mjs +110 -0
  47. package/server/services/image-input-paths.mjs +389 -0
  48. package/server/services/image-operation-service.mjs +6 -2
  49. package/server/services/media-extract.mjs +653 -0
  50. package/server/services/media-index-service.mjs +221 -0
  51. package/server/services/openai-image-preview-turns.mjs +330 -0
  52. package/server/services/openai-image-spend-service.mjs +516 -0
  53. package/server/services/openai-image-stream.mjs +170 -0
  54. package/server/services/plan-usage-openai-image.mjs +148 -0
  55. package/server/services/remote-relay-caller-context.mjs +6 -0
  56. package/server/services/remote-relay-dispatcher.mjs +386 -22
  57. package/server/services/remote-relay-registry-service.mjs +3 -1
  58. package/server/services/token-usage-service.mjs +15 -0
  59. package/shared/image-header.mjs +90 -0
  60. package/shared/openai-image-options.mjs +299 -0
  61. package/shared/openai-image-presets.mjs +175 -0
  62. package/shared/openai-image-previews.mjs +40 -0
  63. package/shared/openai-image-prices.mjs +291 -0
  64. package/shared/openai-image-spend.mjs +90 -0
  65. package/shared/remote-relay-contract.mjs +126 -4
  66. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
@@ -168,7 +168,9 @@ export function createReasoningStreamHandlers({
168
168
  const reasoningId = reasoningIdFor(event);
169
169
  const deltaContent = String(event?.data?.deltaContent ?? event?.data?.delta ?? "");
170
170
  if (!deltaContent) return;
171
- const accumulated = (reasoningAccum.get(reasoningId) || "") + deltaContent;
171
+ // Capped before the gate: past the cap the text stops changing, so the
172
+ // same 16 KiB is not posted again and the accumulator stops growing.
173
+ const accumulated = capThought((reasoningAccum.get(reasoningId) || "") + deltaContent);
172
174
  reasoningAccum.set(reasoningId, accumulated);
173
175
  const lastSent = reasoningLastSent.get(reasoningId) || "";
174
176
  if (!shouldEmitDelta(accumulated, lastSent)) return;
package/CHANGELOG.md CHANGED
@@ -3,6 +3,148 @@
3
3
  All notable changes to OAR are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
+ ## [0.9.25] — 2026-11-09
7
+
8
+ Highlights: a media gallery, and agents that make images. Every picture,
9
+ video and file of a session is now in one place, the Media tab of the 🧠
10
+ modal, and 🖼️ Media in the ︙ menu shows those of every session of the
11
+ relay, with filters and a search; the file viewer moves through the whole
12
+ gallery. Agents drive OpenAI Image sessions through `relay_tool`: they send
13
+ pictures to edit and set the size, quality, count, format and background.
14
+ While gpt-image works, the reply shows partial previews; the composer gets
15
+ a custom size, a count, a format, a background and presets, and the
16
+ gpt-image-2.5 and 2.6 models take xhigh and max quality. Every image turn
17
+ shows what it cost, a daily cap of USD 5 per relay unless you change it
18
+ keeps image spend in check, and a failed image turn costs at most two calls
19
+ instead of up to five. The relay's memory no longer grows while several
20
+ agents stream long thinking at once.
21
+
22
+ ### Added
23
+
24
+ - **Media gallery.** The 🧠 modal has two tabs, Context (where it opens)
25
+ and Media: the session's pictures and videos as a square grid under month
26
+ headers, three columns on a phone, with a Files tab for attached PDFs,
27
+ audio and documents. **🖼️ Media** in the ︙ menu shows the media of every
28
+ session of the relay, with filters (All, Mine, Agent, Generated, a
29
+ provider, a session, Hide archived) and a search. It collects uploads,
30
+ `@file:` pictures, generated images and the pictures agents put in their
31
+ replies; a picture shown in several messages appears once, at its newest
32
+ message, and a deleted local file is left out. A tap opens the file
33
+ viewer, which now moves through the whole gallery (it loads more at the
34
+ end) and offers **Open in session** and **Copy path / link**. The pictures
35
+ on screen that could not load while the relay was away load again once it
36
+ is back; one scrolled out of view waits for the next reconnect.
37
+ Owner only: not in shared views, and no agent reads it through
38
+ `relay_tool`, on this relay or a paired one.
39
+ - **Media index.** The relay keeps the gallery's index in its database
40
+ (migration 0039) and fills it for older messages in the background after
41
+ the upgrade, in short slices.
42
+ - **Agents drive OpenAI Image sessions.** `relay_tool`'s `send` and
43
+ `create_session` take `images`, up to 4 absolute paths of PNG, JPEG or
44
+ WebP files on the target relay, and `image`, the turn's size (any W×H
45
+ within the model's limits), quality, count (1 to 4), output format and
46
+ background; `update_session` with `image` sets a session's own defaults
47
+ (migration 0040). The target relay takes only files inside the session's
48
+ working directory or images one of its sessions generated, follows links
49
+ to the real file, and checks each file's first bytes. Replies list the
50
+ pictures as `images` with their paths there, and a reply without text
51
+ reads like `1 image · 1536x1024 · gpt-image-2`. A paired relay must run
52
+ this version (`imageInputs` in `relay_info`); an older one is refused
53
+ before anything is sent. A new image session without a model starts on
54
+ gpt-image-2.5-flare (else its dated snapshot, else gpt-image-2, else the
55
+ first gpt-image model listed), and is refused when none is listed.
56
+
57
+ - **Image previews.** While a gpt-image model makes an image, the reply's
58
+ bubble shows a timer with the size and quality, then two partial previews
59
+ as OpenAI streams them, then the final image. Previews reach your open
60
+ clients only, never the transcript, shared views, the media gallery or
61
+ agents, and their files go when the turn ends.
62
+ - **Image composer.** An image chat's composer has a Custom… size (any
63
+ W×H the model takes, checked as you type), a Count (1 to 4), a Format
64
+ and a Background for gpt-image models.
65
+
66
+ - **What image turns cost.** Every try of an OpenAI image turn is priced
67
+ as it ends, from the usage OpenAI reports and its published rates, and
68
+ estimated where OpenAI reports no usage for a try that may have been
69
+ billed; a model the relay has no price for counts at the dearest listed
70
+ price. The reply line ends with the cost, the chat menu shows what a
71
+ chat's images cost, and Check Usage has an OpenAI images card.
72
+ - **A daily cap for OpenAI images.** USD 5 per relay and day unless you
73
+ change it (Settings → Providers → OpenAI; 0 is no cap). A request that
74
+ would pass it is not sent, and once it is reached new image messages are
75
+ refused, from you and from agents alike; estimates count toward it. A
76
+ warning shows from 80 %. Allow for today lifts it until midnight.
77
+ - **Image presets.** Five built-in bundles of size, quality, format and
78
+ background, and your own (Save as preset… in the composer); agents name
79
+ one as image.preset.
80
+ - **xhigh and max image quality.** The gpt-image-2.5 and 2.6 models take
81
+ the two higher qualities, in the composer and for agents; other models
82
+ keep auto, low, medium and high. Sizes for gpt-image-2 and later need at
83
+ least 655,360 pixels (1024x640), as OpenAI requires.
84
+
85
+ ### Changed
86
+
87
+ - **OpenAI Image turns.** All attached images go to the edit, in order,
88
+ not only the first, and the format and background reach OpenAI. Quality
89
+ is checked per model family before the call (gpt-image: auto, low,
90
+ medium, high). OpenAI gets 3 minutes for a new image and 5 for
91
+ an edit (`openaiImageGenerateTimeoutMs`, `openaiImageEditTimeoutMs`).
92
+ - **Copilot hides image models.** Copilot's `gpt-image-*` and `dall-e-*`
93
+ ids, which its runtime cannot serve, are left out of its model list; a
94
+ Copilot chat on one is refused with a pointer to OpenAI Image.
95
+
96
+ ### Fixed
97
+
98
+ - **An image turn costs at most two calls.** A timeout, a server error or
99
+ a broken connection is tried once more, then the turn ends with a note
100
+ that says whether the request may have been billed; before, the turn went
101
+ back into the queue up to five times and ended as "Relay timeout after 5
102
+ attempts", each attempt a new billed request. When the second try works,
103
+ the reply notes that the first may have been billed as well. A relay
104
+ restart during the call no longer repeats it either.
105
+ - **The relay's own image route is local only.** A paired relay's token can
106
+ no longer call it directly, past the queue.
107
+ - **An agent's prompt header no longer reaches the image model.**
108
+ - **Relay memory no longer grows while several agents stream long thinking
109
+ at the same time.** A few sessions thinking at length at once could make
110
+ the relay hold more and more memory until it ran out. Thinking still shows
111
+ live; a slow connection may skip some of the updates in between, and the
112
+ whole thought arrives when it ends and after a reconnect, as before.
113
+
114
+ ### Upgrade notes
115
+
116
+ - **The first start takes a little longer on a big database.** Before it
117
+ listens, the relay copies its database to the backups folder, as before,
118
+ and migration 0039 creates the media index and queues every message for
119
+ it once: about 0.2 s per 200,000 messages on a small server. Migration
120
+ 0040 adds two columns. Nothing else waits.
121
+ - **The gallery fills in the background.** After the start the relay
122
+ indexes the older messages in slices of about 20 ms every half second,
123
+ in no particular order: about 3 minutes per 200,000 messages on a small
124
+ server, longer on a phone or with long messages. Until it ends, the
125
+ gallery shows what is indexed and "Indexing older messages… (N left)". A
126
+ restart picks up where it stopped.
127
+ - **The daily cap for OpenAI images is on after the update**: USD 5 per
128
+ relay and day, in Settings → Providers → OpenAI (0 is no cap). Without an
129
+ OpenAI key nothing changes, and Check Usage shows no OpenAI images card
130
+ until something was booked in the month.
131
+ - **Sessions still on 0.9.24's worker wait one worker start.** A session
132
+ whose worker still runs the old code is given a new worker before its
133
+ next message, as in 0.9.24. A Claude session's agent gets `images` and
134
+ `image` in `relay_tool` with its new worker.
135
+ - **Mixed versions work together**, except for images across relays: a
136
+ 0.9.25 relay refuses to send `images` or `image` to a paired 0.9.24 relay
137
+ and sends nothing, and a 0.9.24 relay cannot send them.
138
+ - **Going back to 0.9.24** needs no database step. Install it with
139
+ `npm install -g @oar-sh/oar@0.9.24` (in a git checkout:
140
+ `git checkout v0.9.24`, then a relay restart). 0.9.24 ignores the media
141
+ index and the image defaults and has no daily image cap, so image turns
142
+ are not limited there. The index's triggers stay in the database and
143
+ queue the messages written on 0.9.24, which a later 0.9.25 indexes. To
144
+ remove the index, drop its two triggers first, then its three tables, as
145
+ `server/migrations/0039-media-index.mjs` describes; a table dropped
146
+ before its trigger breaks every new message.
147
+
6
148
  ## [0.9.24] — 2026-11-08
7
149
 
8
150
  Highlights: a much smaller install. The Claude and Cursor agent SDKs leave
package/README.md CHANGED
@@ -45,29 +45,29 @@ flowchart LR
45
45
  <table>
46
46
  <tr>
47
47
  <td width="42%" rowspan="4" valign="top">
48
- <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.24" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
48
+ <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.25" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
49
49
  <br><sub>On a phone: the same conversations, installable as an app.</sub>
50
50
  </td>
51
51
  <td width="58%" valign="top">
52
- <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.24" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
52
+ <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.25" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
53
53
  <br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
54
54
  </td>
55
55
  </tr>
56
56
  <tr>
57
57
  <td valign="top">
58
- <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.24" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
58
+ <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.25" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
59
59
  <br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
60
60
  </td>
61
61
  </tr>
62
62
  <tr>
63
63
  <td valign="top">
64
- <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.24" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
64
+ <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.25" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
65
65
  <br><sub>Read the reasoning, not just the answer.</sub>
66
66
  </td>
67
67
  </tr>
68
68
  <tr>
69
69
  <td valign="top">
70
- <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.24" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
70
+ <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.25" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
71
71
  <br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
72
72
  </td>
73
73
  </tr>
@@ -100,7 +100,7 @@ The runtime (the *provider* in the UI) is chosen in **New Chat** and stays fixed
100
100
  | ------- | ---------- | ------------- |
101
101
  | [**GitHub Copilot**](docs/providers.md#github-copilot--engine-choice) (default) | GitHub's coding agent, with every model your Copilot plan offers | The Copilot CLI, signed in on the relay host |
102
102
  | [**OpenAI (BYOK)**](docs/providers.md#openai-byok-and-openai-image) | Copilot's agent against OpenAI or a compatible endpoint | Your own API key |
103
- | [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images | The same key |
103
+ | [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images, with a daily spending cap; agents can use it too | The same key |
104
104
  | [**Claude**](docs/providers.md#claude-agent-sdk) | Claude Code on your machine | A Claude login, made from **⚙️ Settings** |
105
105
  | [**Claude API**](docs/providers.md#claude-api) | Claude Code on your machine, billed to a Claude Console API key; a Claude chat can switch between the two | A Claude Console API key |
106
106
  | [**Claude Cloud**](docs/providers.md#claude-cloud) (experimental) | Claude Code in a sandbox at Anthropic, on a GitHub repository | A claude.ai account with GitHub connected |
@@ -118,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
118
118
  <table>
119
119
  <tr>
120
120
  <td width="62%" valign="top">
121
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.24" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
121
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.25" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
122
122
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
123
123
  </td>
124
124
  <td width="38%" valign="top">
125
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.24" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
125
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.25" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
126
126
  <br><sub>What each plan has left, in one place.</sub>
127
127
  </td>
128
128
  </tr>
@@ -134,12 +134,13 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
134
134
  - **Background work**: a task panel lists the commands and sub-agents that outlive a reply, each with its own **Stop** and its full record; a command's console shows its output live, says when it has gone quiet and counts a test run's passes and failures; the panel and its consoles come back after a relay restart, and a queued restart shows what it waits for and asks after a maximum wait ([Background tasks](docs/web-ui.md#background-tasks), [After a relay restart](docs/web-ui.md#after-a-relay-restart)).
135
135
  - **Choose how the agent works**: `ask`, `plan`, `agent` or `autopilot`, plus model and reasoning effort, per message, from the composer's **⚙** ([The composer](docs/web-ui.md#the-composer), [Relay modes](docs/web-ui.md#relay-modes)).
136
136
  - **Files and git**: browse the workspace, preview files, review git changes in a diff viewer, and publish a dev server as a preview link ([Files, git, and previews](docs/web-ui.md#files-git-and-previews), [preview servers](docs/preview-servers.md)).
137
- - **Usage and context**: plan limits and credits of every provider, the estimated spend of DeepSeek and Claude API keys, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
137
+ - **Media**: the pictures and videos of a session, or of every session, in one gallery, with a file viewer that moves through all of them ([Media](docs/web-ui.md#media)).
138
+ - **Usage and context**: plan limits and credits of every provider, the estimated spend of DeepSeek and Claude API keys and of OpenAI images, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
138
139
  - **Your conversations everywhere**: the history lives on the relay, drafts follow you from device to device, and conversations can be searched, archived, grouped and pinned ([The conversation list](docs/web-ui.md#the-conversation-list), [drafts](docs/web-ui.md#drafts-across-devices), [session groups](docs/web-ui.md#session-groups), [pins](docs/web-ui.md#pinned-messages)).
139
140
  - **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
140
141
  - **On your phone**: install OAR as an app and get push notifications; back walks through the conversations you opened, a pinch zooms the whole app, and held sideways it keeps a phone's layout ([Notifications and the app](docs/web-ui.md#notifications-and-the-app), [back and forward](docs/web-ui.md#back-and-forward), [zoom](docs/web-ui.md#zoom), [a phone held sideways](docs/web-ui.md#a-phone-held-sideways)).
141
142
 
142
- The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.24](CHANGELOG.md#0924--2026-11-08) as the latest.
143
+ The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.25](CHANGELOG.md#0925--2026-11-09) as the latest.
143
144
 
144
145
  ## Remote access
145
146
 
@@ -190,7 +191,7 @@ flowchart LR
190
191
 
191
192
  You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
192
193
 
193
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.24"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.24"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.24" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
194
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.25"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.25"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.25" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
194
195
 
195
196
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
196
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.24",
3
+ "version": "0.9.25",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "server/",
26
26
  "!server/**/*.test.mjs",
27
27
  "!server/**/*-test-harness.mjs",
28
+ "!server/**/*-test-fixtures.mjs",
28
29
  "!server/**/fixtures/",
29
30
  "!server/README.md",
30
31
  "!server/data/",
@@ -36,6 +37,7 @@
36
37
  "shared/",
37
38
  "!shared/**/*.test.mjs",
38
39
  "!shared/**/*-test-harness.mjs",
40
+ "!shared/**/*-test-fixtures.mjs",
39
41
  "!shared/**/fixtures/",
40
42
  ".github/extensions/web-relay/",
41
43
  "!.github/extensions/web-relay/**/*.test.mjs",
@@ -15,6 +15,7 @@ import {
15
15
  REMOTE_RELAY_THINKING_DISPLAYS,
16
16
  } from '../../shared/remote-relay-contract.mjs';
17
17
  import { RELAY_ASK_URGENCIES } from '../../shared/relay-asks.mjs';
18
+ import { OPENAI_IMAGE_BACKGROUNDS, OPENAI_IMAGE_OUTPUT_FORMATS } from '../../shared/openai-image-options.mjs';
18
19
  import {
19
20
  REMOTE_RELAY_CALL_FAILED,
20
21
  REMOTE_RELAY_TOOL_DESCRIPTION,
@@ -80,6 +81,20 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
80
81
  thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
81
82
  planOnly: z.boolean().optional().describe(FIELDS.planOnly.description),
82
83
  thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
84
+ // A list, or one path as text, which the contract takes as well.
85
+ images: z.union([z.array(z.string()), z.string()]).optional().describe(FIELDS.images.description),
86
+ // Loose like quiet_hours: the contract's validator checks the values per
87
+ // model family and names an unknown key; the two fixed lists are enums as
88
+ // in the JSON schema.
89
+ image: z.looseObject({
90
+ size: z.string().optional().describe(FIELDS.image.properties.size.description),
91
+ quality: z.string().optional().describe(FIELDS.image.properties.quality.description),
92
+ count: z.number().optional().describe(FIELDS.image.properties.count.description),
93
+ output_format: z.enum([...OPENAI_IMAGE_OUTPUT_FORMATS]).optional().describe(FIELDS.image.properties.output_format.description),
94
+ background: z.enum([...OPENAI_IMAGE_BACKGROUNDS]).optional().describe(FIELDS.image.properties.background.description),
95
+ // Free text: the target relay knows its presets and resolves the name.
96
+ preset: z.string().optional().describe(FIELDS.image.properties.preset.description),
97
+ }).optional().describe(FIELDS.image.description),
83
98
  question_id: text('question_id'),
84
99
  // ask: the contract's validator refuses a question or detail that is too
85
100
  // long, or more than eight options, with a message the model can act on.
@@ -555,6 +555,11 @@ export function createSdkMessageNormalizer() {
555
555
  const thinking = state.thinking.get(index);
556
556
  if (thinking) {
557
557
  thinking.text = capThought(thinking.text + String(event?.delta?.thinking || ''));
558
+ // Gated as stream text: a delta can be one token, and every update
559
+ // carries the whole thought. Past the cap the text stops changing,
560
+ // so nothing more goes out until the block's final frame.
561
+ if (!shouldEmitStreamUpdate(thinking.text, thinking.lastEmittedText)) return actions;
562
+ thinking.lastEmittedText = thinking.text;
558
563
  actions.push({
559
564
  channel: 'thought',
560
565
  payload: {
@@ -715,7 +720,7 @@ export function createSdkMessageNormalizer() {
715
720
  const assistantError = lastAssistantError;
716
721
  lastAssistantError = '';
717
722
  const apiErrorStatus = Number(sdkMessage.api_error_status ?? NaN);
718
- return [{
723
+ return [...flushOpenThoughts(), {
719
724
  channel: 'result',
720
725
  payload: {
721
726
  text: String(sdkMessage.result || '').trim(),
@@ -745,6 +750,29 @@ export function createSdkMessageNormalizer() {
745
750
  return [];
746
751
  }
747
752
 
753
+ // A thinking block the turn left open (aborted, failed) never gets its
754
+ // final frame: what the delta gating held back goes out with the result,
755
+ // so the stored thought ends with the last delta.
756
+ function flushOpenThoughts() {
757
+ const actions = [];
758
+ for (const state of threads.values()) {
759
+ for (const thinking of state.thinking.values()) {
760
+ if (!thinking.text.trim() || thinking.text === thinking.lastEmittedText) continue;
761
+ thinking.lastEmittedText = thinking.text;
762
+ actions.push({
763
+ channel: 'thought',
764
+ payload: {
765
+ reasoningId: thinking.reasoningId,
766
+ text: thinking.text,
767
+ done: false,
768
+ subagentRunId: subagentRunIdFor(state.key === 'main' ? null : state.key),
769
+ },
770
+ });
771
+ }
772
+ }
773
+ return actions;
774
+ }
775
+
748
776
  function finalStreamText() {
749
777
  return threadState(null).streamText;
750
778
  }
@@ -5005,10 +5005,12 @@ export function createCopilotSdkSessionRunner({
5005
5005
 
5006
5006
  /**
5007
5007
  * One image-model row (shared/worker-runtime/direct-openai-image.mjs). With
5008
- * an image operation the relay's execute route publishes the reply, the
5009
- * failure or the requeue itself; without one the shared helper publishes
5010
- * the reply, and a refusal by the provider is published here as the row's
5011
- * failure. Anything else requeues the row, as on the extension engine.
5008
+ * an image operation the relay's execute route publishes the reply or the
5009
+ * failure itself; without one the shared helper publishes the reply, and
5010
+ * any failure of the request is published here as the row's failure, never
5011
+ * requeued: the relay already retried it once, and a second run is billed
5012
+ * again. Only an execute call that did not get through requeues the row
5013
+ * (the route refuses to send an operation's request twice).
5012
5014
  */
5013
5015
  async function runDirectImageTurn(message) {
5014
5016
  const id = String(message.id || '');
@@ -5025,11 +5027,11 @@ export function createCopilotSdkSessionRunner({
5025
5027
  dbg('image turn', message.id, result.via, result.outcome);
5026
5028
  } catch (error) {
5027
5029
  if (isOpenAIImageTerminalError(error)) {
5028
- // A refusal by OpenAI, or a reply the relay could not process after
5029
- // OpenAI answered 2xx; the codes are the ones the relay's image
5030
- // operation uses for the same two cases.
5031
- const code = error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected';
5032
- dbg(error.relayProcessing === true ? 'image reply not processed' : 'image turn refused by OpenAI', message.id, error.message);
5030
+ // The relay's note and code (the ones its image operation uses for
5031
+ // the same cases); an older relay names no code.
5032
+ const code = error.imageCode
5033
+ || (error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected');
5034
+ dbg('image turn failed', message.id, code, error.message);
5033
5035
  await publishResponse(message, {
5034
5036
  text: error.message,
5035
5037
  model: null,
@@ -170,6 +170,9 @@ export function createSdkMessageNormalizer() {
170
170
  deltaOwnsThinking = true;
171
171
  if (!openThought) openThought = { reasoningId: allocateThoughtId(), text: '' };
172
172
  openThought.text = capThought(openThought.text + String(update?.text || ''));
173
+ // Gated as stream text; the thought's close sends the whole text.
174
+ if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
175
+ openThought.lastEmittedText = openThought.text;
173
176
  actions.push({
174
177
  channel: 'thought',
175
178
  payload: {
@@ -201,6 +204,14 @@ export function createSdkMessageNormalizer() {
201
204
 
202
205
  if (type === 'turn-ended') {
203
206
  if (update?.usage && typeof update.usage === 'object') lastUsage = update.usage;
207
+ // A thought the turn left open: what the delta gating held back.
208
+ if (openThought?.text.trim() && openThought.text !== openThought.lastEmittedText) {
209
+ openThought.lastEmittedText = openThought.text;
210
+ actions.push({
211
+ channel: 'thought',
212
+ payload: { reasoningId: openThought.reasoningId, text: openThought.text, done: false, subagentRunId: null },
213
+ });
214
+ }
204
215
  return actions;
205
216
  }
206
217
 
@@ -231,6 +242,8 @@ export function createSdkMessageNormalizer() {
231
242
  state.openThought = { reasoningId: `cursor-thought-${runId}-${state.thoughtIndex++}`, text: '' };
232
243
  }
233
244
  state.openThought.text = capThought(state.openThought.text + String(nested.text || ''));
245
+ if (!shouldEmitStreamUpdate(state.openThought.text, state.openThought.lastEmittedText)) return actions;
246
+ state.openThought.lastEmittedText = state.openThought.text;
234
247
  actions.push({
235
248
  channel: 'thought',
236
249
  payload: {
@@ -44,6 +44,8 @@ import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan
44
44
  import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
45
45
  import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
46
46
  import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
47
+ import { migrateMediaIndex } from './migrations/0039-media-index.mjs';
48
+ import { migrateImageSessionDefaults } from './migrations/0040-image-session-defaults.mjs';
47
49
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
48
50
 
49
51
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -1096,6 +1098,23 @@ try {
1096
1098
  console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
1097
1099
  }
1098
1100
 
1101
+ // The media gallery index: tables, the two queue triggers on messages and a
1102
+ // one-time seed of the queue. The gallery works without it (empty), so a
1103
+ // failure is logged and retried next boot.
1104
+ try {
1105
+ migrateMediaIndex(db);
1106
+ } catch (error) {
1107
+ console.warn(`[media-index] migration 0039 failed; retrying on next boot: ${error?.message || error}`);
1108
+ }
1109
+
1110
+ // An image session's own size/quality/count defaults. Image turns run on the
1111
+ // built-in defaults without it, so a failure is logged and retried next boot.
1112
+ try {
1113
+ migrateImageSessionDefaults(db);
1114
+ } catch (error) {
1115
+ console.warn(`[image-sessions] migration 0040 failed; retrying on next boot: ${error?.message || error}`);
1116
+ }
1117
+
1099
1118
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
1100
1119
  if (!conversationColumns.includes('archived')) {
1101
1120
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
@@ -148,6 +148,9 @@ export function createSdkMessageNormalizer() {
148
148
  openThought = { reasoningId: allocateThoughtId(), text: '' };
149
149
  }
150
150
  openThought.text = capThought(openThought.text + chunk);
151
+ // Gated as stream text; the thought's close sends the whole text.
152
+ if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
153
+ openThought.lastEmittedText = openThought.text;
151
154
  actions.push({
152
155
  channel: 'thought',
153
156
  payload: {
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the media gallery index.
5
+ *
6
+ * `media_items`: one row per picture, video or attached file of a message
7
+ * (services/media-extract.mjs decides what counts). Rows go with their
8
+ * message or conversation (foreign keys, ON DELETE CASCADE). `message_ts`
9
+ * copies the message time so the gallery pages by an index.
10
+ *
11
+ * `media_index_queue`: the ids of messages to (re)index. Two triggers on
12
+ * `messages` put a row id here on insert and when a column the index reads
13
+ * changes; they never parse anything, so the write path pays one small
14
+ * insert. The indexer (services/media-index-service.mjs) drains the queue in
15
+ * batches. `seq` grows on every re-queue, so a drain only removes the queue
16
+ * row it actually indexed.
17
+ *
18
+ * `media_index_meta`: the extractor version the queue was seeded for. When
19
+ * it is missing (first run) or older than MEDIA_EXTRACTOR_VERSION, every
20
+ * message is queued once and the version is stored; later boots seed nothing.
21
+ *
22
+ * Not in SCHEMA_SQL (db-schema.mjs): the triggers name columns that older
23
+ * migrations add. The triggers are checked on every boot and re-created when
24
+ * missing or different, like the FTS triggers.
25
+ *
26
+ * Additive and idempotent. Rolling back: drop the triggers first, then the
27
+ * tables (a trigger left pointing at a dropped table breaks message inserts):
28
+ * DROP TRIGGER IF EXISTS media_index_on_insert;
29
+ * DROP TRIGGER IF EXISTS media_index_on_update;
30
+ * DROP TABLE IF EXISTS media_index_queue; DROP TABLE IF EXISTS media_items;
31
+ * DROP TABLE IF EXISTS media_index_meta;
32
+ *
33
+ * Usage: node server/migrations/0039-media-index.mjs [path/to/oar.db]
34
+ */
35
+
36
+ import { fileURLToPath } from 'url';
37
+ import path from 'path';
38
+ import process from 'process';
39
+ import Database from 'better-sqlite3';
40
+ import { cliDbPath } from '../services/database-file.mjs';
41
+ import { MEDIA_EXTRACTOR_VERSION } from '../services/media-extract.mjs';
42
+
43
+ export const MEDIA_INDEX_TRIGGERS = Object.freeze(['media_index_on_insert', 'media_index_on_update']);
44
+ // Columns the index reads; a change to one re-queues the message.
45
+ const WATCHED_COLUMNS = Object.freeze(['text', 'attachments', 'timestamp', 'role', 'kind', 'origin_json', 'conversation_id']);
46
+
47
+ const QUEUE_UPSERT = `INSERT INTO media_index_queue (message_id, seq) VALUES (NEW.id, 0)
48
+ ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1;`;
49
+
50
+ function tableExists(db, name) {
51
+ return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
52
+ }
53
+
54
+ function columnsOf(db, table) {
55
+ return db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
56
+ }
57
+
58
+ export function mediaIndexTriggerSql(messageColumns) {
59
+ const watched = WATCHED_COLUMNS.filter((column) => messageColumns.includes(column));
60
+ return {
61
+ media_index_on_insert: `CREATE TRIGGER media_index_on_insert AFTER INSERT ON messages BEGIN
62
+ ${QUEUE_UPSERT}
63
+ END`,
64
+ media_index_on_update: `CREATE TRIGGER media_index_on_update AFTER UPDATE OF ${watched.join(', ')} ON messages BEGIN
65
+ ${QUEUE_UPSERT}
66
+ END`,
67
+ };
68
+ }
69
+
70
+ export function migrateMediaIndex(db) {
71
+ if (!tableExists(db, 'messages') || !tableExists(db, 'conversations')) {
72
+ return { applied: false, tablesCreated: 0, triggersCreated: 0, seeded: 0 };
73
+ }
74
+ let tablesCreated = 0;
75
+ let triggersCreated = 0;
76
+ let seeded = 0;
77
+ const run = db.transaction(() => {
78
+ if (!tableExists(db, 'media_items')) {
79
+ db.exec(`
80
+ CREATE TABLE media_items (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
83
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
84
+ ordinal INTEGER NOT NULL,
85
+ role TEXT NOT NULL,
86
+ sender TEXT NOT NULL,
87
+ origin TEXT NOT NULL,
88
+ locator_kind TEXT NOT NULL,
89
+ locator TEXT NOT NULL,
90
+ identity_key TEXT NOT NULL,
91
+ category TEXT NOT NULL,
92
+ media_kind TEXT NOT NULL,
93
+ mime TEXT,
94
+ name TEXT,
95
+ alt TEXT,
96
+ size_bytes INTEGER,
97
+ message_ts TEXT NOT NULL,
98
+ UNIQUE (message_id, ordinal)
99
+ );
100
+ CREATE INDEX idx_media_items_conv ON media_items(conversation_id, category, message_ts DESC, id DESC);
101
+ CREATE INDEX idx_media_items_all ON media_items(category, message_ts DESC, id DESC);
102
+ CREATE INDEX idx_media_items_identity ON media_items(identity_key);
103
+ `);
104
+ tablesCreated += 1;
105
+ }
106
+ if (!tableExists(db, 'media_index_queue')) {
107
+ db.exec(`
108
+ CREATE TABLE media_index_queue (
109
+ message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
110
+ seq INTEGER NOT NULL DEFAULT 0
111
+ )
112
+ `);
113
+ tablesCreated += 1;
114
+ }
115
+ if (!tableExists(db, 'media_index_meta')) {
116
+ db.exec(`CREATE TABLE media_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
117
+ tablesCreated += 1;
118
+ }
119
+
120
+ const expected = mediaIndexTriggerSql(columnsOf(db, 'messages'));
121
+ for (const name of MEDIA_INDEX_TRIGGERS) {
122
+ const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?`).get(name);
123
+ if (row?.sql === expected[name]) continue;
124
+ if (row) db.exec(`DROP TRIGGER ${name}`);
125
+ db.exec(expected[name]);
126
+ triggersCreated += 1;
127
+ }
128
+
129
+ const version = db.prepare(`SELECT value FROM media_index_meta WHERE key = 'extractor_version'`).get()?.value;
130
+ if (String(version || '') !== String(MEDIA_EXTRACTOR_VERSION)) {
131
+ seeded = db.prepare(`
132
+ INSERT INTO media_index_queue (message_id, seq)
133
+ SELECT id, 0 FROM messages WHERE true
134
+ ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1
135
+ `).run().changes;
136
+ db.prepare(`INSERT INTO media_index_meta (key, value) VALUES ('extractor_version', ?)
137
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(MEDIA_EXTRACTOR_VERSION));
138
+ }
139
+ });
140
+ run();
141
+ return { applied: tablesCreated + triggersCreated + seeded > 0, tablesCreated, triggersCreated, seeded };
142
+ }
143
+
144
+ export function migrate(dbPath, options = {}) {
145
+ const db = new Database(dbPath, options);
146
+ try {
147
+ return migrateMediaIndex(db);
148
+ } finally {
149
+ db.close();
150
+ }
151
+ }
152
+
153
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
154
+ if (isMain) {
155
+ try {
156
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
157
+ const result = migrate(dbPath);
158
+ console.log(`Migration 0039 on ${dbPath}`);
159
+ console.log(result.applied
160
+ ? ` media index: tables created ${result.tablesCreated}, triggers created ${result.triggersCreated}, messages queued ${result.seeded}`
161
+ : ' skipped: nothing to change');
162
+ process.exit(0);
163
+ } catch (err) {
164
+ console.error(`Migration 0039 failed: ${err?.message || err}`);
165
+ process.exit(1);
166
+ }
167
+ }